Feat/door improvement#293
Conversation
wass08
left a comment
There was a problem hiding this comment.
door-interaction.ts in packages/editor/src/lib/ currently runs a requestAnimationFrame loop, mutating useInteractive per frame and committing to useScene on completion. It works, but it parks animation logic in the editor layer when animation is fundamentally a viewer concern. This blocks two things:
- The read-only
/viewer/[id]route can't animate doors — it can't reach into@pascal-app/editor(perviewer-isolation.md). - The editor maintains its own frame clock (
window.requestAnimationFrame) that's independent of R3F's render loop, so animation timing isn't synchronized with the rest of the scene and won't pause/resume with the tab the way R3F's clock does.
We already have the right pattern in the codebase: InteractiveSystem in packages/viewer/src/systems/interactive/ handles item control values driving mesh animation. Doors with swingAngle/operationState are the same shape — make this consistent.
The change, at a high level
Three pieces, each in its right layer:
1. Extend useInteractive (core) with an animation queue.
Add a doorAnimations map and a startDoorAnimation / cancelDoorAnimation setter pair. These are pure state writes — no Three.js, no rAF, no timing. The store just holds the active tween descriptors (field, from, to, startedAt, durationMs, persist).
2. Add DoorAnimationSystem in packages/viewer/src/systems/door/.
A React component that renders nothing and uses useFrame to advance every active animation: read the queue from useInteractive, compute the eased value for each, write it via setDoorOpenState, mark the door (and parent wall) dirty. When a tween reaches t === 1: if persist, commit the final value via useScene.updateNode and clear the runtime entry; otherwise just leave the runtime value in place. Then remove the entry from doorAnimations. Mount the system inside <Viewer> next to the existing systems.
3. Shrink door-interaction.ts to a thin trigger.
Keep the constants (DOOR_SWING_OPEN_ANGLE, etc.) and replace animateDoorOpenState with a small toggleDoorOpenState(doorId) that picks the right field/from/to based on the door's doorType, then calls useInteractive.getState().startDoorAnimation(...). All callers (use-keyboard.ts, first-person-controls.tsx, anything else) switch to this one-liner. No rAF anywhere on the editor side.
Things to watch for
onCompletecallbacks. The currentanimateDoorOpenStateaccepts an optionalonComplete?: () => void. Every current call site uses fire-and-forget, so the simplest path is to drop it. If a future need arises, emit on the event bus (emitter.emit('door:animation-completed', { doorId, field })) — don't store callbacks in the zustand store.- Cancellation.
startDoorAnimationshould overwrite any existing entry for the same door (toggle-while-animating should reverse smoothly from the current value, not snap). The system reads the current displayed value fromuseInteractive.doors[id]rather thanfrom, so the new tween starts at the visible position. - Dirty propagation. Make sure the system marks both the door node and its parent wall dirty each frame an animation is running, same as the current code does — otherwise the wall-cutout won't update.
- Mount order. Place
<DoorAnimationSystem />before<DoorSystem />inpackages/viewer/src/components/viewer/index.tsxso per-frame state writes land before the renderer's frame-end reconciliation. - Persistence semantics. Today's
door-interaction.tsdefaultspersist: true. Keep that default in the new API so undo/redo still captures door toggles as one history entry.
Why now, not later
The PR works correctly and shipping it doesn't break anything, so the temptation is to file this as a follow-up. The reason to do it before merging the main door work into the broader codebase: each new caller of animateDoorOpenState (panel buttons, future automation, walkthrough triggers) hardens the wrong shape and makes the eventual refactor more invasive. Refactor is mostly mechanical — state moves into the store, the rAF loop becomes a useFrame, callers swap to a thin wrapper. Doing it as part of this PR keeps the door domain clean from the start instead of carrying technical debt forward.
Acceptance criteria
door-interaction.tscontains norequestAnimationFramecalls and no module-level animation state map.packages/editor/src/lib/has no rAF orchestration around door state.packages/viewer/src/systems/door/door-animation-system.tsxexists and is mounted inside<Viewer>.useInteractiveexposesstartDoorAnimation/cancelDoorAnimationand adoorAnimationsmap; the existingsetDoorOpenState/removeDoorOpenStatestay (the system uses them internally).- Door toggling from the keyboard, first-person interaction, and any panel control all go through the same
toggleDoorOpenState(or equivalent) thin wrapper. - Behavior is unchanged: same easing, same ~520ms duration, same persistence default, same end-state writes to scene history.
# Conflicts: # packages/core/src/events/bus.ts # packages/viewer/src/systems/door/door-system.tsx
What does this PR do?
This PR significantly expands the door system with new door types, garage doors, animated open/close behavior, and improved first-person interaction.
It enables:
Door Types
Garage Doors
Animation & Interaction
Rkey toggles door stateDoor Behavior Improvements
Garage Door Mechanics
First-Person View (FPV)
Architecture Improvements
Bug Fixes